昨天 FastAPI 應用已經能跑,也能查到 Day 5 建立的測試資料。不過那些資料仍是用腳本預先寫入的。
今天要做第一支讓使用者實際填寫資料的 API。使用者可以告訴系統自己的程度、每週可投入時間與學習主題;系統會把資訊存起來,供 Day 13 生成計畫時使用。
今天完成後,你會有:
POST /users/profile:建立或更新學習檔案GET /users/profile:查詢學習檔案RESTful 是一套「用網址和方法表達你要做什麼」的慣例。
想像成圖書館的服務窗口:同一個窗口(網址)根據你遞的單子種類(HTTP 方法)做不同的事。遞「借書單」(POST)就是新增一筆借閱紀錄,遞「查詢單」(GET)就是查資料,不會另外開一個窗口叫「新增借閱窗口」。
對應到今天:
POST /users/profile → 新增或更新一份學習檔案
GET /users/profile → 查詢一份學習檔案
同一個網址 /users/profile,方法不同,做的事情不同。這是 RESTful 最基本也最常用的模式。
Day 27 才會做真正的登入認證(JWT)。今天還沒有「系統怎麼知道你是誰」的機制,所以先用最簡單的方式代替:呼叫 API 時自己帶上 user_id。等 Day 27 做完登入,user_id 會改成從登入資訊自動判斷,不用使用者自己填,但資料庫結構和邏輯不用改。
FastAPI 搭配 Pydantic 可以清楚定義「使用者能送什麼進來」「系統會回什麼出去」,格式不對會自動被擋下來。
檔案位置: backend/schemas.py
狀態: 新增檔案
用途: 定義 API 請求與回應的資料格式(Pydantic Schema)
依賴: pydantic(已隨 fastapi 安裝)
from typing import Literal
from pydantic import BaseModel, Field
class ProfileIn(BaseModel):
"""建立或更新學習檔案時,前端要送過來的格式"""
user_id: int
level: Literal["初級", "中級", "高級"]
available_hours: float = Field(gt=0, description="每週可用時間,必須大於 0")
learning_topic: str = Field(min_length=1, description="想學的主題")
class ProfileOut(BaseModel):
"""回傳給前端的學習檔案格式"""
id: int
user_id: int
level: str
available_hours: float
learning_topic: str
class Config:
from_attributes = True # 允許直接從 SQLAlchemy Model 轉換
Literal["初級", "中級", "高級"] 讓 level 只能填這三個值中的一個,填別的字串會直接被拒絕,不用自己寫 if-else 檢查。Field(gt=0) 讓 available_hours 一定要大於 0。
檔案位置: backend/main.py
狀態: 修改檔案(接續Day 6的內容,繼續往下加)
用途: 新增建立或更新學習檔案的POST端點
依賴: fastapi, schemas, models
from fastapi import HTTPException
from schemas import ProfileIn, ProfileOut
from models import Profile, User
@app.post("/users/profile", response_model=ProfileOut)
def create_or_update_profile(
payload: ProfileIn, db: Session = Depends(get_db)
) -> Profile:
"""建立或更新使用者的學習檔案,若已存在則覆蓋更新"""
user = db.query(User).filter(User.id == payload.user_id).first()
if user is None:
raise HTTPException(status_code=404, detail="找不到這個使用者")
profile = db.query(Profile).filter(Profile.user_id == payload.user_id).first()
if profile is None:
# 第一次建立
profile = Profile(
user_id=payload.user_id,
level=payload.level,
available_hours=payload.available_hours,
learning_topic=payload.learning_topic,
)
db.add(profile)
else:
# 已存在,更新內容
profile.level = payload.level
profile.available_hours = payload.available_hours
profile.learning_topic = payload.learning_topic
db.commit()
db.refresh(profile)
return profile
程式會先確認 user_id 存在,不存在就回傳 404。接著檢查使用者是否已有檔案:有就更新,沒有就新增。這是簡單的「建立或更新」(upsert)做法。
檔案位置: backend/main.py
狀態: 修改檔案(接續步驟2,繼續往下加)
用途: 新增查詢學習檔案的GET端點
依賴: fastapi, schemas, models
@app.get("/users/profile", response_model=ProfileOut)
def get_profile(user_id: int, db: Session = Depends(get_db)) -> Profile:
"""查詢指定使用者的學習檔案"""
profile = db.query(Profile).filter(Profile.user_id == user_id).first()
if profile is None:
raise HTTPException(status_code=404, detail="這個使用者還沒有建立學習檔案")
return profile
user_id: int 沒有寫在路徑裡,FastAPI 會自動把它當成網址參數(query parameter),呼叫時網址會長這樣:/users/profile?user_id=1。
用 /docs 頁面測試最不容易出錯:
http://127.0.0.1:8000/docs
POST /users/profile,按「Try it out」user_id 是 1):
{
"user_id": 1,
"level": "初級",
"available_hours": 10,
"learning_topic": "AWS Solutions Architect 認證"
}
Response body 應該回傳建立好的檔案,包含自動產生的 id
也可以用 Python 一行測試:
python -c "import requests; print(requests.post('http://127.0.0.1:8000/users/profile', json={'user_id': 1, 'level': '初級', 'available_hours': 10, 'learning_topic': 'AWS Solutions Architect 認證'}).json())"
在 /docs 找到 GET /users/profile,按「Try it out」,user_id 填 1,按「Execute」,應該看到剛剛建立的資料原封不動地回來。
回到 POST /users/profile,故意送一個錯的 level:
{
"user_id": 1,
"level": "超級高手",
"available_hours": 10,
"learning_topic": "測試"
}
按「Execute」應該收到 422 Unprocessable Entity,錯誤訊息會告訴你 level 只能是三個選項之一。再試著把 available_hours 填成 -5,也應該被擋下來。這表示 API 已確實驗證輸入資料。
{"detail": "找不到這個使用者"}user_id 對不到真實用戶。先確認 Day 5 有跑過 python init_db.py,或用 GET /users/count 確認資料庫裡真的有用戶。
response_model=ProfileOut 是做什麼的?限制回傳的欄位只能是 ProfileOut 定義的那些,就算 SQLAlchemy 的 Profile 物件裡有其他欄位,也不會意外洩漏出去。
user_id 而不是 profile_id?因為使用者只會關心「我的檔案」,不需要知道也不用記自己的 profile_id,用 user_id 查更符合實際使用情境。
user_id 建立第二次檔案,資料會怎樣?會被覆蓋更新成第二次送的內容,不會產生兩筆重複資料,這就是步驟2的 upsert 邏輯。
Literal 和資料庫裡的 level 欄位型別不一致會怎樣?不會,Profile.level 是 String,能接受任何字串。Literal 是在 API 這一層先擋掉不合法的值,資料庫本身不知道也不需要知道這個限制。
今天完成使用者學習檔案 API。POST /users/profile 能建立或更新檔案,GET /users/profile 能查詢資料;不符合格式的輸入會在進入資料庫前被擋下。
目前進度:
Day 1 ✓ 產品定義完成
Day 2 ✓ 開發環境準備
Day 3 ✓ 專案架構設計
Day 4 ✓ 資料庫設計
Day 5 ✓ SQLite 資料庫建置
Day 6 ✓ FastAPI 基礎
Day 7 ✓ 使用者檔案 API(今天)
Day 8 ⬜ 理解 LLM Agent 的本質
使用者現在可以把目標與可投入時間交給系統,不再依賴測試腳本裡的固定資料。明天(Day 8)會先釐清 Agent 的概念,以及為什麼後續要使用 LangGraph。